Micron Document
🎖️GitЯра🎖️

Commit 8cf821416219dd5bffe7fed6d8cbe2339669e50f


Parents : 500ab7a
Author : Jeremiah K <17190268+jeremiah-k@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-24T11:29:10-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-24T16:29:10Z

fix(hardware): throttle repeated catalog refreshes for cache misses (#6399)

Changes
Diff

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
index 4fcbe24947..4fd631f6fc 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
@@ -28,8 +28,8 @@ class DeviceHardwareLocalDataSource(private val dbManager: DatabaseProvider) {
dbManager.withDb { it.deviceHardwareDao().insertAll(deviceHardware.map { hw -> hw.asEntity() }) }
}
- suspend fun deleteAllDeviceHardware() {
- dbManager.withDb { it.deviceHardwareDao().deleteAll() }
+ suspend fun replaceAllDeviceHardware(deviceHardware: List<NetworkDeviceHardware>) {
+ dbManager.withDb { it.deviceHardwareDao().replaceAll(deviceHardware.map { hw -> hw.asEntity() }) }
}
suspend fun getByHwModel(hwModel: Int): List<DeviceHardwareEntity> =

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
index 4363ab4f83..56a695ec01 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.data.repository
import co.touchlab.kermit.Logger
+import kotlinx.atomicfu.atomic
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -39,6 +40,39 @@ import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.DeviceLinkRepository
+import kotlin.time.Duration.Companion.minutes
+
+/**
+ * Bounds catalog refreshes when a model is missing or permanently incomplete. A successful full-catalog response is
+ * authoritative for [successTtlMs]; failed attempts may retry after [retryIntervalMs]. This prevents packet-driven UI
+ * lookups from turning an unsupported model into a continuous hardware + device-link network refresh loop.
+ */
+internal class DeviceHardwareRefreshGate(private val retryIntervalMs: Long, private val successTtlMs: Long) {
+ private val lastAttemptMs = atomic(NO_TIMESTAMP)
+ private val lastSuccessMs = atomic(NO_TIMESTAMP)
+
+ fun shouldRefresh(nowMs: Long, forceRefresh: Boolean, cacheNeedsRefresh: Boolean): Boolean = when {
+ forceRefresh -> true
+ !cacheNeedsRefresh -> false
+ isWithin(nowMs, lastSuccessMs.value, successTtlMs) -> false
+ else -> !isWithin(nowMs, lastAttemptMs.value, retryIntervalMs)
+ }
+
+ fun recordAttempt(nowMs: Long) {
+ lastAttemptMs.value = nowMs
+ }
+
+ fun recordSuccess(nowMs: Long) {
+ lastSuccessMs.value = nowMs
+ }
+
+ private fun isWithin(nowMs: Long, timestampMs: Long, intervalMs: Long): Boolean =
+ timestampMs != NO_TIMESTAMP && nowMs >= timestampMs && nowMs - timestampMs < intervalMs
+
+ private companion object {
+ private const val NO_TIMESTAMP = -1L
+ }
+}
@Single
class DeviceHardwareRepositoryImpl(
@@ -53,13 +87,26 @@ class DeviceHardwareRepositoryImpl(
/**
* Shared full-table refresh; a caller that stops waiting (the node-details path bounds its wait) can't abort it.
*/
+ private val refreshGate =
+ DeviceHardwareRefreshGate(
+ retryIntervalMs = FAILED_REFRESH_RETRY_INTERVAL_MS,
+ successTtlMs = CACHE_EXPIRATION_TIME_MS,
+ )
+
private val refresher =
SingleFlightRefresher(dispatchers.io, "DeviceHardwareRepository") {
+ refreshGate.recordAttempt(nowMillis)
Logger.d { "DeviceHardwareRepository: fetching from remote API" }
val remoteHardware = remoteDataSource.getAllDeviceHardware()
Logger.d { "DeviceHardwareRepository: remote returned ${remoteHardware.size} entries" }
- localDataSource.insertAllDeviceHardware(remoteHardware)
- // Refresh msh.to device links from the API after a hardware refresh.
+ if (remoteHardware.isNotEmpty()) {
+ localDataSource.replaceAllDeviceHardware(remoteHardware)
+ refreshGate.recordSuccess(nowMillis)
+ } else {
+ Logger.w { "DeviceHardwareRepository: remote catalog was empty; retaining cached data" }
+ }
+ // Refresh msh.to device links from the API after a hardware refresh. Hardware freshness is recorded first:
+ // a link-refresh failure must not cause another full hardware fetch on the next packet-driven lookup.
deviceLinkRepository.reconcile()
}
@@ -82,14 +129,14 @@ class DeviceHardwareRepositoryImpl(
}
if (forceRefresh) {
- Logger.d { "DeviceHardwareRepository: forceRefresh=true, clearing cache" }
- localDataSource.deleteAllDeviceHardware()
+ Logger.d { "DeviceHardwareRepository: forceRefresh=true, bypassing refresh gate" }
}
ensureSeeded()
var entities = lookupEntities(hwModel, target)
- if (forceRefresh || entities.isEmpty() || entities.any { it.isStale() }) {
+ val cacheNeedsRefresh = entities.isEmpty() || entities.any { it.isStale() }
+ if (refreshGate.shouldRefresh(nowMillis, forceRefresh, cacheNeedsRefresh)) {
refresher.refresh(maxWaitMs = NETWORK_REFRESH_TIMEOUT_MS)
entities = lookupEntities(hwModel, target)
}
@@ -106,7 +153,10 @@ class DeviceHardwareRepositoryImpl(
ensureSeeded()
resolveHardware(hwModel, lookupEntities(hwModel, target), target)
},
- shouldFetch = { cached -> cached == null || lookupEntities(hwModel, target).any { it.isStale() } },
+ shouldFetch = { cached ->
+ val cacheNeedsRefresh = cached == null || lookupEntities(hwModel, target).any { it.isStale() }
+ refreshGate.shouldRefresh(nowMillis, forceRefresh = false, cacheNeedsRefresh)
+ },
fetch = { refresher.refresh() },
context = dispatchers.io,
networkTimeoutMs = null,
@@ -185,6 +235,7 @@ class DeviceHardwareRepositoryImpl(
companion object {
private val CACHE_EXPIRATION_TIME_MS = TimeConstants.ONE_DAY.inWholeMilliseconds
+ private val FAILED_REFRESH_RETRY_INTERVAL_MS = 15.minutes.inWholeMilliseconds
/** Maximum time a blocking lookup waits for an in-flight refresh before returning cached/bundled data. */
private const val NETWORK_REFRESH_TIMEOUT_MS = 5_000L

diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt
new file mode 100644
index 0000000000..9d9c1af40c
--- /dev/null
+++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt
@@ -0,0 +1,237 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.repository
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.json.Json
+import okio.Buffer
+import okio.Source
+import org.meshtastic.core.data.datasource.BundledAssetReader
+import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.DeviceLink
+import org.meshtastic.core.model.EventFirmwareResponse
+import org.meshtastic.core.model.FirmwareReleaseManifest
+import org.meshtastic.core.model.NetworkDeviceHardware
+import org.meshtastic.core.model.NetworkDeviceLinksResponse
+import org.meshtastic.core.model.NetworkFirmwareNightly
+import org.meshtastic.core.model.NetworkFirmwareReleases
+import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
+import org.meshtastic.core.network.service.ApiService
+import org.meshtastic.core.repository.DeviceLinkRepository
+import org.meshtastic.core.testing.FakeDatabaseProvider
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class DeviceHardwareRepositoryImplTest {
+ private class FakeApiService(var response: List<NetworkDeviceHardware>) : ApiService {
+ var hardwareCalls = 0
+ var responseGate: CompletableDeferred<Unit>? = null
+
+ override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> {
+ hardwareCalls += 1
+ responseGate?.await()
+ return response
+ }
+
+ override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = error("unused")
+
+ override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused")
+
+ override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = error("unused")
+
+ override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused")
+
+ override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused")
+ }
+
+ private class FakeBundledAssetReader(var hardware: List<NetworkDeviceHardware>, private val json: Json) :
+ BundledAssetReader {
+ override fun open(name: String): Source? {
+ if (name != "device_hardware.json") return null
+ return Buffer().write(json.encodeToString(hardware).encodeToByteArray())
+ }
+ }
+
+ private class FakeDeviceLinkRepository : DeviceLinkRepository {
+ var reconcileCalls = 0
+ var reconcileFailure: Throwable? = null
+
+ override suspend fun ensureImported() = Unit
+
+ override suspend fun reconcile() {
+ reconcileCalls += 1
+ reconcileFailure?.let { throw it }
+ }
+
+ override suspend fun getLinksForTarget(platformioTarget: String, regionCode: String): List<DeviceLink> =
+ emptyList()
+
+ override fun observeAllLinks(): Flow<List<DeviceLink>> = flowOf(emptyList())
+ }
+
+ private val json = Json { ignoreUnknownKeys = true }
+ private val dispatchers =
+ CoroutineDispatchers(Dispatchers.Unconfined, Dispatchers.Unconfined, Dispatchers.Unconfined)
+ private val knownHardware =
+ NetworkDeviceHardware(
+ hwModel = 1,
+ hwModelSlug = "KNOWN",
+ platformioTarget = "known",
+ architecture = "esp32",
+ activelySupported = true,
+ displayName = "Known",
+ images = listOf("known.svg"),
+ )
+
+ private val remoteOnlyHardware =
+ NetworkDeviceHardware(
+ hwModel = 2,
+ hwModelSlug = "REMOTE_ONLY",
+ platformioTarget = "remote-only",
+ architecture = "esp32",
+ activelySupported = true,
+ displayName = "Remote Only",
+ images = listOf("remote-only.svg"),
+ )
+
+ private lateinit var databaseProvider: FakeDatabaseProvider
+ private lateinit var api: FakeApiService
+ private lateinit var links: FakeDeviceLinkRepository
+ private lateinit var repository: DeviceHardwareRepositoryImpl
+
+ @BeforeTest
+ fun setup() {
+ databaseProvider = FakeDatabaseProvider()
+ api = FakeApiService(listOf(knownHardware))
+ links = FakeDeviceLinkRepository()
+ repository =
+ DeviceHardwareRepositoryImpl(
+ remoteDataSource = DeviceHardwareRemoteDataSource(api, dispatchers),
+ localDataSource = DeviceHardwareLocalDataSource(databaseProvider),
+ assetReader = FakeBundledAssetReader(listOf(knownHardware), json),
+ json = json,
+ deviceLinkRepository = links,
+ dispatchers = dispatchers,
+ )
+ }
+
+ @AfterTest fun tearDown() = databaseProvider.close()
+
+ @Test
+ fun repeatedMissingModelUsesOneSuccessfulCatalogRefresh() = runBlocking {
+ // Sequential lookups can hit cache after the first fetch and never share a flight; prove single-flight
+ // sharing by suspending the fake API response and overlapping the two lookups.
+ api.responseGate = CompletableDeferred()
+ coroutineScope {
+ val first = async { repository.getDeviceHardwareByModel(hwModel = 37) }
+ val second = async { repository.getDeviceHardwareByModel(hwModel = 37) }
+ api.responseGate!!.complete(Unit)
+ awaitAll(first, second)
+ }
+
+ assertEquals(1, api.hardwareCalls, "concurrent callers must share one fetch")
+ assertEquals(1, links.reconcileCalls, "concurrent callers must share one reconcile")
+ }
+
+ @Test
+ fun reconciliationFailureDoesNotTriggerAnotherCatalogRefresh() = runBlocking {
+ // The refresher records catalog success BEFORE calling reconcile(), so a reconcile failure must not
+ // poison the success TTL window. A subsequent missing-model lookup must NOT cause another hardware
+ // fetch, because the catalog was already recorded as fresh.
+ links.reconcileFailure = RuntimeException("reconcile boom")
+ assertNull(repository.getDeviceHardwareByModel(hwModel = 37).getOrThrow())
+
+ links.reconcileFailure = null
+ assertNull(repository.getDeviceHardwareByModel(hwModel = 37).getOrThrow())
+
+ assertEquals(1, api.hardwareCalls, "catalog success must gate further hardware fetches")
+ assertEquals(1, links.reconcileCalls, "retry outside TTL must not retry reconcile either")
+ }
+
+ @Test
+ fun forceRefreshBypassesRecentSuccessfulCatalogRefresh() = runBlocking {
+ repository.getDeviceHardwareByModel(hwModel = 37).getOrThrow()
+ repository.getDeviceHardwareByModel(hwModel = 37, forceRefresh = true).getOrThrow()
+
+ assertEquals(2, api.hardwareCalls)
+ assertEquals(2, links.reconcileCalls)
+ }
+
+ @Test
+ fun emptyForcedRefreshPreservesPreviouslyCachedRemoteCatalog() = runBlocking {
+ api.response = listOf(remoteOnlyHardware)
+ val initial = repository.getDeviceHardwareByModel(hwModel = remoteOnlyHardware.hwModel).getOrThrow()
+ assertEquals(remoteOnlyHardware.displayName, initial?.displayName)
+ assertNull(repository.getDeviceHardwareByModel(hwModel = knownHardware.hwModel).getOrThrow())
+
+ api.response = emptyList()
+ val afterEmptyRefresh =
+ repository.getDeviceHardwareByModel(hwModel = remoteOnlyHardware.hwModel, forceRefresh = true).getOrThrow()
+
+ assertEquals(remoteOnlyHardware.displayName, afterEmptyRefresh?.displayName)
+ assertEquals(2, api.hardwareCalls)
+ assertEquals(2, links.reconcileCalls)
+ }
+}
+
+class DeviceHardwareRefreshGateTest {
+ private val gate = DeviceHardwareRefreshGate(retryIntervalMs = 100, successTtlMs = 1_000)
+
+ @Test
+ fun freshCacheNeedsNoRefresh() {
+ assertFalse(gate.shouldRefresh(nowMs = 0, forceRefresh = false, cacheNeedsRefresh = false))
+ }
+
+ @Test
+ fun failedAttemptIsThrottledUntilRetryInterval() {
+ assertTrue(gate.shouldRefresh(nowMs = 0, forceRefresh = false, cacheNeedsRefresh = true))
+ gate.recordAttempt(0)
+
+ assertFalse(gate.shouldRefresh(nowMs = 99, forceRefresh = false, cacheNeedsRefresh = true))
+ assertTrue(gate.shouldRefresh(nowMs = 100, forceRefresh = false, cacheNeedsRefresh = true))
+ }
+
+ @Test
+ fun successfulCatalogIsAuthoritativeForTtl() {
+ gate.recordAttempt(0)
+ gate.recordSuccess(10)
+
+ assertFalse(gate.shouldRefresh(nowMs = 1_009, forceRefresh = false, cacheNeedsRefresh = true))
+ assertTrue(gate.shouldRefresh(nowMs = 1_010, forceRefresh = false, cacheNeedsRefresh = true))
+ }
+
+ @Test
+ fun forceRefreshAlwaysBypassesGate() {
+ gate.recordAttempt(100)
+ gate.recordSuccess(100)
+
+ assertTrue(gate.shouldRefresh(nowMs = 101, forceRefresh = true, cacheNeedsRefresh = true))
+ }
+}

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
index ae188d1785..a95e1befae 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
@@ -18,6 +18,7 @@ package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Query
+import androidx.room3.Transaction
import androidx.room3.Upsert
import org.meshtastic.core.database.entity.DeviceHardwareEntity
@@ -27,6 +28,14 @@ interface DeviceHardwareDao {
@Upsert suspend fun insertAll(deviceHardware: List<DeviceHardwareEntity>)
+ /** Replaces the full hardware catalog atomically after a successful non-empty remote response. */
+ @Transaction
+ suspend fun replaceAll(deviceHardware: List<DeviceHardwareEntity>) {
+ require(deviceHardware.isNotEmpty()) { "Device hardware catalog must not be empty" }
+ deleteAll()
+ insertAll(deviceHardware)
+ }
+
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
suspend fun getByHwModel(hwModel: Int): List<DeviceHardwareEntity>

Served by rngit 1.5.4 - Generated in 0.05s